Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

25장. 동시성 패턴과 도구 모음

지금까지 우리가 손에 쥔 것들:

  • 22장 — 고루틴과 채널, select, WaitGroup
  • 23장 — 뮤텍스, race detector, atomic
  • 24장 — 락 없이 설계하는 발상들

이번 장은 그 도구들을 실제로 자주 쓰는 조합으로 묶는다. 파이프라인, fan-in/fan-out, 워커 풀, 그리고 취소/타임아웃을 다루는 context 패키지까지.

마지막엔 동시성 코드의 단골 버그인 고루틴 누수와 이를 추적하는 디버깅 팁을 다룬다.

목표:

  • 자주 쓰이는 동시성 패턴 4종을 손에 익히기
  • context 로 취소/타임아웃을 전파하는 법 익히기
  • 동시성 코드의 흔한 함정을 디버깅하는 감각 잡기

25.1 파이프라인 패턴

데이터를 여러 단계로 흘려보내는 구조.

[stage 1] --chan--> [stage 2] --chan--> [stage 3]

각 단계는 고루틴이고, 단계 사이를 채널이 잇는다. 한 단계는,

  • 입력 채널에서 값을 받고
  • 처리해서
  • 출력 채널로 보낸다

비유하면 공장 컨베이어 벨트다.

예제: 숫자 생성 → 제곱 → 합계

package main

import "fmt"

// 단계 1: 1..n 을 생성
func gen(n int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; i <= n; i++ {
			out <- i
		}
	}()
	return out
}

// 단계 2: 각 수를 제곱
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for v := range in {
			out <- v * v
		}
	}()
	return out
}

// 단계 3: 합계 (수신 측에서 처리)
func main() {
	c1 := gen(5)
	c2 := square(c1)

	sum := 0
	for v := range c2 {
		sum += v
	}
	fmt.Println(sum) // 1+4+9+16+25 = 55
}

장점:

  • 단계별로 관심사가 또렷이 분리된다
  • 각 단계의 동시성을 따로 조절할 수 있다
  • 입력이 무한히 들어와도 메모리에 모두 쌓지 않는다 (스트리밍)

종료 규약

파이프라인을 안전하게 끝내는 규약 두 가지.

  1. 보내는 쪽이 닫는다.
    • 단계가 끝나면 자신의 출력 채널을 close
  2. 닫힌 채널은 range 가 자동 종료
    • 다음 단계가 자연스럽게 끝난다

이 규약이 지켜지면 마지막 단계까지 도미노처럼 조용히 끝난다.

중간 단계에서 일찍 끝내야 할 땐? 그건 취소(cancellation) 의 영역이다. 25.4 의 context 가 이를 담당한다.


25.2 Fan-out / Fan-in

파이프라인의 한 단계가 너무 무거우면 같은 단계를 여러 고루틴이 나눠 처리하게 만들 수 있다.

  • Fan-out — 하나의 입력 채널에서 여러 워커가 동시에 값을 가져간다
  • Fan-in — 여러 워커의 출력 채널을 하나의 채널로 합친다
        ┌──→ worker 1 ──┐
입력 ──┤   worker 2    ├──→ 합치기 ──→ 다음 단계
        └──→ worker 3 ──┘

Fan-out

특별한 코드가 필요 없다. 같은 채널을 여러 고루틴이 range 로 읽으면 한 값은 한 워커에게만 전달된다.

func startWorkers(in <-chan int, n int) []<-chan int {
	outs := make([]<-chan int, n)
	for i := 0; i < n; i++ {
		out := make(chan int)
		go func() {
			defer close(out)
			for v := range in {
				out <- v * v
			}
		}()
		outs[i] = out
	}
	return outs
}

각 워커는 자기만의 출력 채널을 만들고, 공용 입력 채널을 함께 읽는다.

Fan-in

여러 채널을 하나로 합치는 함수는 정해진 모양이 있다.

import "sync"

func merge(ins ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	wg.Add(len(ins))

	for _, in := range ins {
		in := in // 루프 변수 캡처 회피
		go func() {
			defer wg.Done()
			for v := range in {
				out <- v
			}
		}()
	}

	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

흐름:

  • 입력 채널마다 고루틴 하나가 붙어 출력으로 옮긴다
  • 모든 입력이 닫혀 끝나면 wg.Wait 가 풀린다
  • 그 직후 out 을 닫는다

“닫는 시점을 누가 책임지는가” 가 fan-in 의 핵심이다. 별도의 고루틴이 WaitGroup 을 보고 닫는 패턴이 정석이다.

합쳐서

func main() {
	c1 := gen(20)
	outs := startWorkers(c1, 3)
	merged := merge(outs...)

	for v := range merged {
		fmt.Println(v)
	}
}

워커 3개가 병렬로 제곱을 처리하고, 결과가 하나의 채널로 모인다. 순서는 더 이상 보장되지 않지만, 처리량은 올라간다.


25.3 워커 풀

가장 자주 쓰이는 동시성 패턴이라 따로 짚는다.

  • 고정된 N 개의 워커 고루틴
  • 공용 작업 채널에 작업을 던지면
  • 워커들이 알아서 하나씩 가져가 처리한다

장점:

  • 고루틴 수를 제어할 수 있다 (무작정 띄우다가 OOM 나는 사고 방지)
  • 외부 자원(파일, DB, API) 동시 접근 수를 제한할 수 있다

정식 구현

package main

import (
	"fmt"
	"sync"
)

type Job struct {
	ID  int
	Val int
}

type Result struct {
	ID  int
	Out int
}

func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		out := j.Val * j.Val // 실제 작업
		results <- Result{ID: j.ID, Out: out}
		fmt.Printf("worker %d 처리 %d\n", id, j.ID)
	}
}

func main() {
	const numWorkers = 3
	const numJobs = 10

	jobs := make(chan Job)
	results := make(chan Result, numJobs)

	var wg sync.WaitGroup
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, jobs, results, &wg)
	}

	// 작업 투입
	go func() {
		defer close(jobs)
		for i := 0; i < numJobs; i++ {
			jobs <- Job{ID: i, Val: i}
		}
	}()

	// 워커가 모두 끝나면 결과 채널을 닫는다
	go func() {
		wg.Wait()
		close(results)
	}()

	for r := range results {
		fmt.Printf("결과 %d -> %d\n", r.ID, r.Out)
	}
}

핵심 규약 정리:

누가무엇을 닫나
작업 투입 고루틴jobs 채널을 닫는다
별도 감시 고루틴wg.Waitresults 닫는다
워커자기는 채널을 닫지 않는다

이 분담이 정석이다. 워커가 results 를 직접 닫으려 하면, 다른 워커가 거기 송신하다 패닉이 난다.

워커 수 정하기

  • I/O 가 많은 작업: CPU 코어 수보다 많이 띄워도 된다
    • 대부분 시간 동안 대기 중이라 코어를 다 안 쓴다
  • CPU 가 많은 작업: 코어 수 정도가 적당하다
    • runtime.NumCPU() 로 가져올 수 있다

“그냥 1000명 띄우자” 는 거의 항상 잘못된 답이다. 어디선가 자원이 막혀 결국 더 느려진다.


25.4 context 패키지

지금까지 본 파이프라인/워커 풀에는 한 가지가 빠져 있다.

“이제 그만, 다 멈춰.” 를 어떻게 전달할까?

타임아웃, 사용자 취소, 부모 요청 취소. 이런 신호를 호출 트리 전체에 전파해 주는 도구가 표준 라이브러리 context 패키지다.

왜 따로 도구가 필요한가

채널 하나 만들어 “취소” 라고 부르면 안 되나?

사실 그게 핵심 아이디어 그대로다. context 는 그 아이디어를 표준화하고, 거기에 다음을 더 얹은 것이다.

  • 취소 채널 + “왜 끝났는지” 사유 (Err)
  • 자동 타임아웃 / 데드라인
  • 호출 트리 따라 자식 컨텍스트로 전파
  • 요청 범위 값(request-scoped value) 운반

기본 컨텍스트

ctx := context.Background()  // 루트 (보통 main, 서버 진입점)
ctx := context.TODO()        // "아직 정하지 않았다" 용 placeholder

대부분 함수의 첫 매개변수로 ctx context.Context 를 받는다.

취소 가능한 컨텍스트

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
  • ctx.Done() — 취소 시 닫히는 채널
  • ctx.Err() — 왜 끝났는지 (context.Canceled 또는 DeadlineExceeded)
  • cancel() — 직접 취소 (꼭 호출해 줘야 리소스 누수가 없다)

타임아웃 / 데드라인

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

// 또는
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
defer cancel()

지정한 시간이 지나면 ctx.Done() 이 자동으로 닫힌다.

사용 패턴

select 와 함께 쓰는 게 정석이다.

func work(ctx context.Context, in <-chan int) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case v, ok := <-in:
			if !ok {
				return nil
			}
			process(v)
		}
	}
}

ctx.Done() 케이스를 첫 번째에 두는 게 관례다.

예제: HTTP 요청 타임아웃

import (
	"context"
	"net/http"
	"time"
)

func fetch(ctx context.Context, url string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, err
	}
	return http.DefaultClient.Do(req)
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	resp, err := fetch(ctx, "https://example.com")
	if err != nil {
		fmt.Println("실패:", err)
		return
	}
	defer resp.Body.Close()
	// ...
}
  • 2초 안에 응답이 오지 않으면 자동 취소
  • http.Clientctx.Done() 을 보고 연결을 끊는다

호출 트리 따라 전파

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go a(ctx)   // a 안에서 다시 b(ctx) 호출
go c(ctx)   // c 안에서 다시 d(ctx) 호출

부모를 취소하면 자식들도 같이 취소된다. 즉, 취소가 트리 전체에 흐른다. 이 자동 전파가 context 의 진짜 가치다.

컨텍스트 값 (간단히)

ctx := context.WithValue(parent, key, value)
v := ctx.Value(key)

요청 단위 메타데이터(사용자 ID, 요청 ID 등)를 옮길 때 쓴다.

함정: 함수 매개변수 대용으로 쓰면 안 된다. 진짜 함수 인자는 함수 인자로 받자. 컨텍스트 값은 요청 범위 메타데이터 한정.

context 권장 규약

  • 함수 시그니처의 첫 번째 매개변수ctx context.Context
  • 구조체 필드로 보관하지 않는다 (수명 추적이 망가진다)
  • nil 컨텍스트는 넘기지 않는다 (context.TODO() 사용)
  • cancel() 은 항상 호출 (보통 defer cancel())

25.5 그 밖의 동기화 도구

표준 라이브러리에는 자주 쓰진 않지만 알아 두면 유용한 도구가 더 있다.

sync.Once

어떤 작업을 단 한 번만 실행하고 싶을 때.

var (
	once sync.Once
	conn *Connection
)

func GetConn() *Connection {
	once.Do(func() {
		conn = openConnection()
	})
	return conn
}

여러 고루틴이 동시에 GetConn 을 호출해도 openConnection딱 한 번만 실행된다. 다른 호출자는 첫 번째가 끝날 때까지 기다린다.

쓰임:

  • 싱글톤 초기화
  • 전역 캐시 워밍업
  • 무거운 설정 로딩

sync.Map

동시 접근에 안전한 맵이다.

var m sync.Map

m.Store("k", 1)
v, ok := m.Load("k")
m.Delete("k")
m.Range(func(k, v interface{}) bool {
	fmt.Println(k, v)
	return true
})

언제 쓸까?

공식 문서가 권장하는 좁은 경우는 이렇다.

  • 키 집합이 한 번 채워진 뒤 거의 변하지 않고 읽기만 많을 때
  • 또는 서로 다른 고루틴이 서로 다른 키만 만질 때

그 외에는 보통 map[K]V + sync.Mutex 가 더 빠르고 읽기 쉽다. “동시성 안전한 맵이 필요해 → sync.Map” 식의 반사적 선택은 피하자.

또한 sync.Map 은 타입 안전하지 않다 (interface{}). 제네릭 시대에는 좀 어색하게 느껴지는 API다.

sync.Cond

조건 변수(condition variable). “어떤 조건이 만족될 때까지 기다린다” 를 표현하는 저수준 도구.

var (
	mu   sync.Mutex
	cond = sync.NewCond(&mu)
	data []int
)

// 소비자
mu.Lock()
for len(data) == 0 {
	cond.Wait() // mu 를 풀고 잠들어 있다가 깨면 다시 잠근다
}
v := data[0]
data = data[1:]
mu.Unlock()

// 생산자
mu.Lock()
data = append(data, 42)
cond.Signal()  // 또는 cond.Broadcast()
mu.Unlock()

기능은 강력하지만 Go 에선 거의 채널로 대체 가능하다. 공식 문서도 “보통은 채널이 더 낫다” 고 안내한다.

→ 존재만 알아 두고, 처음엔 채널로 풀어 보자.


25.6 고루틴 누수

동시성 코드의 가장 흔한 사고 중 하나는 고루틴 누수다.

어떻게 새나

고루틴이 끝나지 않고 영원히 멈춰 있는 경우. 주범은 거의 항상 채널이다.

예제 1: 받는 사람이 없는 송신

func leak() {
	ch := make(chan int)
	go func() {
		ch <- 1 // 받는 사람이 없어 영원히 막힌다
	}()
	// ch 를 한 번도 읽지 않는다
}

leak 이 끝나도 안의 고루틴은 끝나지 못한다. 호출할 때마다 고루틴이 하나씩 쌓인다.

예제 2: 보내는 사람이 없는 수신

func wait(ch <-chan int) {
	v := <-ch // 누군가 보낼 때까지 영원히 대기
	fmt.Println(v)
}

호출자가 ch 를 닫지도, 보내지도 않으면 이 고루틴은 영원히 잠든다.

예제 3: 취소 없는 무한 루프

func poll() {
	for {
		check()
		time.Sleep(time.Second)
	}
}
go poll()

종료 신호를 받을 길이 없다. 프로세스가 죽기 전엔 살아 있다.

한 줄 진단

고루틴 누수는 보통 “끝나는 조건을 안 줬다” 의 다른 이름이다.

막는 법

대원칙:

고루틴을 만들었으면 언제, 어떻게 끝나는지 같이 정해 두자.

도구별 처방:

  • 채널 송신이 막힐 위험이 있다면
    • 받는 사람이 항상 있도록 설계
    • 또는 select + ctx.Done() 으로 빠져나갈 길
  • 채널 수신이 막힐 위험이 있다면
    • 송신자가 다 끝나면 채널을 닫는다
    • select + ctx.Done() 으로 빠져나갈 길
  • 무한 루프 고루틴은
    • 반드시 ctx context.Context 를 받고
    • 한 번씩 <-ctx.Done() 체크

context 적용 예

위 예제를 누수 없는 형태로 고치면,

func poll(ctx context.Context) {
	t := time.NewTicker(time.Second)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			check()
		}
	}
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go poll(ctx)

종료 시점에 cancel() 하면 고루틴이 깔끔히 끝난다.


25.7 동시성 디버깅 팁

마지막으로, 동시성 코드와 친해지는 데 도움이 되는 실용 팁 몇 가지.

(1) -race 는 기본으로 켠다

go test -race ./...

CI 에서 항상 켜 두자. 없을 땐 보이지 않던 버그가 켜고 나면 줄줄이 보이는 경우가 흔하다.

운영 배포에선 끄지만 (5~10배 느리다), 개발/테스트 단계의 표준 도구로 자리잡혀야 한다.

(2) 고루틴 수 모니터링

runtime.NumGoroutine() 으로 현재 고루틴 수를 알 수 있다.

import "runtime"

fmt.Println("goroutines:", runtime.NumGoroutine())

운영 서버에서는 시간에 따라 이 값이 서서히 증가만 하면 고루틴 누수다.

가벼운 패턴:

  • HTTP 핸들러 시작/끝에 로그
  • 요청 처리량은 비슷한데 고루틴 수만 늘면 의심
  • expvar 등으로 메트릭에 노출

(3) pprof 의 goroutine 프로파일

언급만 짧게.

  • import _ "net/http/pprof" 와 디버그 서버
  • go tool pprof http://.../debug/pprof/goroutine
  • 현재 살아 있는 고루틴들의 스택 트레이스를 본다

“어디서 멈춰 있는지” 가 한 번에 보이므로 누수 추적의 결정타가 된다.

자세한 사용법은 26장(pprof 절)에서 다룬다.

(4) 작은 단위로 격리해서 테스트

동시성 버그는 큰 시스템에서 잡기 어렵다. 의심되는 부분을 떼어 내 짧은 테스트로 재현해 보자.

  • 작은 카운터 1000번 증가
  • 채널 송수신 100번 반복
  • go test -race -count=100 ./...

-count=N 옵션은 같은 테스트를 N번 반복 실행한다. 드물게 터지는 버그를 잡는 데 효과적이다.

(5) 가능한 한 결정론적으로

테스트는 시간에 의존하지 않게 짠다.

  • time.Sleep 으로 “충분히 기다리겠지” 는 금물
  • 채널이나 WaitGroup 으로 명시적 동기화
  • 시간 자체가 필요하면 추상화 (Clock 인터페이스)

(6) 정리 체크리스트

코드 리뷰 때 다음을 떠올려 보자.

  • 모든 고루틴에 종료 경로가 있는가?
  • 채널은 누가 닫는지 명확한가?
  • 락 안에서 시간이 오래 걸리는 작업을 하지 않는가?
  • 락을 두 개 이상 잡는다면 순서가 정해져 있는가?
  • 공유 상태 옆에 // 보호: mu 같은 주석이 있는가?
  • -race 로 한 번 돌려 봤는가?

이 정도만 챙겨도 사고가 크게 줄어든다.


25.8 정리

이 장에서 살펴본 내용:

  • 파이프라인: 단계별 채널로 데이터를 흘려보낸다
  • Fan-out / Fan-in: 한 단계를 여러 워커로 병렬화하고 결과를 하나로 합친다
  • 워커 풀: 고정된 N개의 워커가 작업 채널을 나눠 처리한다
  • context 패키지로 취소/타임아웃을 호출 트리 전체에 전파한다
  • sync.Once, sync.Map, sync.Cond 는 좁은 상황에서 유용한 보조 도구다
  • 고루틴 누수는 “끝나는 조건이 없다” 의 다른 이름이고, context 와 채널 닫기 규약으로 막는다
  • 디버깅의 첫걸음은 -race, 고루틴 수 추적, pprof

여기까지가 Go 동시성의 큰 그림이다.

  • 22장에서 도구를 익히고
  • 23장에서 위험을 배우고
  • 24장에서 설계로 위험을 줄이고
  • 25장에서 실전 패턴으로 묶었다

다음 부에서는 시야를 다른 쪽으로 돌린다. 대용량 데이터와 메모리 효율 이야기다. 슬라이스, 포인터, 스트리밍 처리, 벤치마크와 pprof 같은 “성능을 의식한 코드” 의 기초를 본격적으로 다룬다.